Modern Python part 2: ユニットテストの作成とGitのコミット規約の適用
著者:Faouzi BRAZA Jun 24, 2021
はじめに
優れたソフトウェアエンジニアリングの実践は、常に多くの長期的な利益をもたらします。たとえば、ユニットテストを書くことで、大規模なコードベースを維持することができ、コードの特定の部分が期待通りに動作することを保証します。また、一貫性のある Git コミットを行うことで、プロジェクトの関係者間のコラボレーションを促進することができます。適切に作成されたGitのコミットメッセージは、自動バージョン管理や変更ログファイルの生成を可能にします。そのため、Git コミットに書かれたメッセージを正規化するためのさまざまな試みが行われています。
このシリーズの最初のパートでは、pyenvを使って異なるバージョンのPythonをインストールし、pyenvを使ってPythonのローカルバージョンを設定し、poetryを使って仮想環境にカプセル化することで、プロジェクトのセットアップを行いました。ここでは、Pythonアプリケーションをユニットテストする方法と、Gitのコミットメッセージを強制的に検証する方法をより正確に示します。この記事に関連するソースコードはGitHubで公開されています。
この記事は、ベスト・プラクティスを紹介する3つのシリーズの2番目の記事です。
日本語訳:Modern Python part 2: ユニットテストの作成とGitのコミット規約の適用
コードのテスト
このプロジェクトは、pandas DataFrameに存在するデータを要約するシンプルなpython関数です。この関数は、pandas DataFrameに存在する各データタイプの行数、列数、頻度を出力します。
code: pytohn
---- Data Summary ------
Values
Number of rows 230
Number of columns 9
float64 3
int64 4
object 2
プロジェクトのルートディレクトリに移動し、仮想環境を起動します。
code: bash
$ poetry shell
poetryを使って、いくつかの依存関係のパッケージを追加します。
code: bash
$ poetry add -D pynvim numpy pandas
Using version ^0.4.3 for pynvim
Using version ^1.20.2 for numpy
Using version ^1.2.3 for pandas
Updating dependencies
Resolving dependencies... (1.4s)
Writing lock file
Package operations: 8 installs, 0 updates, 0 removals
• Installing six (1.15.0)
• Installing greenlet (1.0.0)
• Installing msgpack (1.0.2)
• Installing numpy (1.20.2)
• Installing python-dateutil (2.8.1)
• Installing pytz (2021.1)
• Installing pandas (1.2.3)
• Installing pynvim (0.4.3)
-D フラグは、その依存関係が開発環境にのみ適用されることを示します。
私は個人的にNeoVimを使ってコーディングをしているので、NeoVimのpythonプラグインをサポートするpynvimパッケージが必要なのです。(訳注:NeoVim はLinuxで使用されるエディタ)
上記の期待される出力に基づいて、私たちのプログラムは3つのステップで構成されています。
pandasのDataFrameの形状を取得します。
pandas dtypesの頻度を取得する。
2つの結果を連結して、最終結果を出力するために使用する統一されたDataFrameを作成します。
最終的なDataFrameが得られたら、上の図のように結果を出力します。この点について、私たちのコード・スカフォールドは以下のようになります。
code: python
import pandas as pd
def data_summary(df: pd.DataFrame) -> None:
"""
Function defined to return a DataFrame containing details
about the number of rows and columns and the column dtype
frequency of the passed pandas DataFrame
"""
def _shape(df: pd.DataFrame) -> None:
"""
Function defined to return a dataframe with details about
the number of row and columns
"""
return None
def _dtypes_freq(df: pd.DataFrame) -> None:
"""
Function defined to return a dataframe with details about
the pandas dtypes frequency
"""
return None
return None
def display_summary(df: pd.DataFrame) -> None:
"""
Function define to print out the result of the data summary
"""
result_df = True
message = '---- Data summary ----'
print(message, result_df, sep='\n')
それでは、ユニットテストを書き始めましょう。ここでは、Python標準ライブラリで提供されているunittestツールを使用します。前回の記事で、pytest がテストのために開発者に依存していると定義されていたことを覚えているでしょう。pytest は unittest ライブラリで書かれたテストをネイティブに実行するので、問題にはなりません。
ユニットテストとは、unittestがPythonクラス内に記述することを期待する単一のメソッドです。テストクラスとメソッドには、説明的な名前をつけましょう。テストメソッドの名前は test_ で始まるべきです。さらに、unittest は unittest.TestCase クラスから継承した一連の特別なアサーションメソッドを使用します。実際には、テストは1つの機能を正確にカバーし、外部からの合図を必要としない自律的なものであり、成功する条件を再現する必要があります。
必要な環境を再現するために、セットアップコードを書かなければなりません。もしこのコードが冗長になるようであれば、setUp()メソッドを実装し、毎回のテストの前に実行します。これは、コードを再利用したり再編成したりするのにかなり便利です。ユースケースによっては、テストの実行後にシステム的な操作を行う必要があるかもしれません。そのために tearDown() メソッドを使うことになります。
まず、data_summary() 関数に実装したユニットテストを以下に示します。
code: python
import unittest
import pandas as pd
from summarize_dataframe.summarize_df import data_summary
class TestDataSummary(unittest.TestCase):
def setUp(self):
# initialize dataframe to test
df_data = 1, 'a'], 2, 'b', [3, 'c' self.df = pd.DataFrame(data=df_data, columns=df_cols)
# initialize expected dataframe
self.exp_df = pd.DataFrame(data=exp_data, columns=exp_col, index=exp_idx)
def test_data_summary(self):
expected_df = self.exp_df
result_df = data_summary(self.df)
self.assertTrue(expected_df.equals(result_df))
if __name__ == '__main__':
unittest.main()
setUp() メソッドは、2つの異なるpandas DataFrameを初期化します。self.exp_df は、data_summary() 関数を呼び出した後に得られると予想される結果のDataFrameであり、self.df は、我々の関数をテストするために使用されるものです。現時点では、テストは失敗することが予想されます。このロジックはまだ実装されていません。poetryでテストするには、コマンドを使用します。
code: bash
$ poetry run pytest -v
============================================== test session starts ==============================
platform linux -- Python 3.8.7, pytest-5.4.3, py-1.10.0, pluggy-0.13.1 -- /home/fbraza/.cache/pypoetry/virtualenvs/summarize-dataframe-SO-g_7pj-py3.8/bin/python
cachedir: .pytest_cache
rootdir: /home/fbraza/Documents/python_project/summarize_dataframe
collected 1 item
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary FAILED 100% =============================================== FAILURES =========================================
___________________________________TestDataSummary.test_data_summary _____________________________
self = <tests.test_summarize_dataframe.TestDataSummary testMethod=test_data_summary>
def test_data_summary(self):
expected_df = self.exp_df
result_df = data_summary(self.df)
self.assertTrue(expected_df.equals(result_df))
E AssertionError: False is not true
tests/test_summarize_dataframe.py:26: AssertionError
============================================== short test summary info =============================
FAILED tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary - AssertionError: False is not true
============================================== 1 failed in 0.32s ===================================
-v フラグを使用すると、テストの結果がより詳細に出力されます。テストは、あなたが付けたクラスや関数の名前に従ってラベル付けされていることがわかります。(例:test_summarize_dataframe.py::TestDataSummary::test_data_summary)
コードをユニットテストにパスするように更新しましょう。
code: python
import pandas as pd
def data_summary(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to output details about the number
of rows and columns and the column dtype frequency of
the passed pandas DataFrame
"""
def _shape(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the number of row and columns
"""
row, col = df.shape
def _dtypes_freq(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the pandas dtypes frequency
"""
counter, types = {}, df.dtypes
for dtype in types:
tmp = str(dtype)
if tmp in counter.keys():
else:
values = [value for value in counter.values()] return pd.DataFrame(data=values, columns='Values', index=list(counter.keys())) return result_df
def display_summary(df: pd.DataFrame) -> None:
"""
Function define to print out the result of the data summary
"""
result_df = True
message = '---- Data summary ----'
print(message, result_df, sep='\n')
もう一度テストを実行してみます。
code: bash
$ poetry run pytest -v
=============================================== test session starts ===============================================================
platform linux -- Python 3.8.7, pytest-5.4.3, py-1.10.0, pluggy-0.13.1 -- /home/fbraza/.cache/pypoetry/virtualenvs/summarize-dataframe-SO-g_7pj-py3.8/bin/python
cachedir: .pytest_cache
rootdir: /home/fbraza/Documents/python_project/summarize_dataframe
collected 1 item
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary PASSED 100% =============================================== 1 passed in 0.28s =================================================================
ここで最後に一つ。今回のテストでは、実際の出力をテストしていません。私たちのモジュールは、DataFrameの概要を文字列で出力するように設計されています。この目的を達成するために、unittestを使ったソリューションがあります。しかし、私たちはこのテストにpytestを使用します。意外でしょうか?先に述べたように、pytest は unittest と非常にうまく補間します。このテストのコードは以下の通りです。
code: python
import unittest
import pytest
import pandas as pd
from summarize_dataframe.summarize_df import data_summary, display_summary
class TestDataSummary(unittest.TestCase):
def setUp(self):
# initialize dataframe to test
df_data = 1, 'a'], 2, 'b', [3, 'c' self.df = pd.DataFrame(data=df_data, columns=df_cols)
# initialize expected dataframe
self.exp_df = pd.DataFrame(data=exp_data, columns=exp_col, index=exp_idx)
@pytest.fixture(autouse=True)
def _pass_fixture(self, capsys):
self.capsys = capsys
def test_data_summary(self):
expected_df = self.exp_df
result_df = data_summary(self.df)
self.assertTrue(expected_df.equals(result_df))
def test_display(self):
print('---- Data summary ----', self.exp_df, sep='\n')
expected_stdout = self.capsys.readouterr()
display_summary(self.df)
result_stdout = self.capsys.readouterr()
self.assertEqual(expected_stdout, result_stdout)
if __name__ == '__main__':
unittest.main()
デコレータ @pytest.fixture(autouse=True) と、それをカプセル化した関数 _pass_fixture()に注目してください。ユニットテストの用語では、このメソッドをフィクスチャと呼びます。フィクスチャは関数 (OOP アプローチを使用する場合はメソッド) であり、それが適用される各テストの前に実行されます。フィクスチャは、いくつかのデータをテストに与えるために使われます。フィクスチャは、前に使った setUp() メソッドと同じ目的を果たします。ここでは、capsys という定義済みのフィクスチャを使って、標準出力 (stdout) を取得し、それをテストで再利用しています。そして、それに応じて display_summary() のコードを修正します。
code: python
import pandas as pd
def data_summary(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to output details about the number
of rows and columns and the column dtype frequency of
the passed pandas DataFrame
"""
def _shape(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the number of row and columns
"""
row, col = df.shape
def _dtypes_freq(df: pd.DataFrame) -> pd.DataFrame:
"""
Function defined to return a dataframe with details about
the pandas dtypes frequency
"""
counter, types = {}, df.dtypes
for dtype in types:
tmp = str(dtype)
if tmp in counter.keys():
else:
values = [value for value in counter.values()] return pd.DataFrame(data=values, columns='Values', index=list(counter.keys())) return result_df
def display_summary(df: pd.DataFrame) -> None:
"""
Function define to print out the result of the data summary
"""
result_df = data_summary(df)
message = '---- Data summary ----'
print(message, result_df, sep='\n')
再度、テストを実施してみます。
code: bash
$ poetry run pytest -v
=============================================== test session starts ===============================================================
platform linux -- Python 3.8.7, pytest-5.4.3, py-1.10.0, pluggy-0.13.1 -- /home/fbraza/.cache/pypoetry/virtualenvs/summarize-dataframe-SO-g_7pj-py3.8/bin/python
cachedir: .pytest_cache
rootdir: /home/fbraza/Documents/python_project/summarize_dataframe
collected 2 items
tests/test_summarize_dataframe.py::TestDataSummary::test_data_summary PASSED 50% tests/test_summarize_dataframe.py::TestDataSummary::test_display PASSED 100% =============================================== 2 passed in 0.29s =================================================================
テストが成功しました。そろそろ作業をコミットして、たとえば GitHub に公開するなどして共有することにしましょう。その前に、Git のコミットメッセージを使って作業内容を正しく伝えるにはどうしたらいいのか、共通の基準を尊重して実行するにはどうしたらいいのかを詳しく見ていきましょう。
プロジェクトにGitコミットメッセージのルールを適用する
commitizen の使用
JavaScript の monorepos についてのシリーズでは、コミットメッセージに関するグッドプラクティスを実施するために、これらの規約を統合する方法を見てきました。Pythonではcommizenというパッケージを使ってこれを実現します。このパッケージを開発者用の依存パッケージに追加しましょう。
code: bash
$ poetry add -D commitizen
Using version ^2.17.0 for commitizen
Updating dependencies
Resolving dependencies... (3.1s)
Writing lock file
Package operations: 11 installs, 0 updates, 0 removals
• Installing markupsafe (1.1.1)
• Installing prompt-toolkit (3.0.18)
• Installing argcomplete (1.12.2)
• Installing colorama (0.4.4)
• Installing decli (0.5.2)
• Installing jinja2 (2.11.3)
• Installing pyyaml (5.4.1)
• Installing questionary (1.6.0)
• Installing termcolor (1.1.0)
• Installing tomlkit (0.7.0)
• Installing commitizen (2.17.0)
プロジェクトにcommizenを設定するには、cz init コマンドを実行します。一連の質問が表示されます。
code: bash
$ cz init
? Please choose a supported config file: (default: pyproject.toml) (Use arrow keys)
» pyproject.toml
.cz.toml
.cz.json
cz.json
.cz.yaml
cz.yaml
? Please choose a cz (commit rule): (default: cz_conventional_commits) (Use arrow keys)
» cz_conventional_commits
cz_jira
cz_customize
? Please enter the correct version format: (default: "$version")
? Do you want to install pre-commit hook? (Y/n)
これらの質問には、実際の状況にぴったり合うデフォルトの選択肢をすべて選んでおきます。最後の質問では、プリコミットフック(pre-commit hook) を使用するかどうかを尋ねています。これについては後ほど触れることになるでしょう。なので、今は「N」と答えてください。pyproject.tomlファイルを見ると、[tool.commitizen] という新しいエントリが追加されているのがわかります。 code: pyproject.toml
name = "cz_conventional_commits" # commit rule chosen
version = "0.0.1"
tag_format = "$version"
コミットメッセージを確認するには、以下のコマンドを使用します。
code: bash
$ cz check -m "all summarize_data tests now succeed"
commit validation: failed!
please enter a commit message in the commitizen format.
commit "": "all summarize_data tests now succeed"
pattern: (build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert|bump)!?(\(\S+\))?:(\s.*)
コミットメッセージは、コミットルールを無視しているため、拒否されます。最後の行では、使用するパターンをいくつか提案しています。時間をかけて conventional commits ドキュメントを読み、cz infoコマンドを実行して短いドキュメントを印刷してください。 code: bash
$ cz info
The commit contains the following structural elements, to communicate intent to the consumers of your library:
fix: a commit of the type fix patches a bug in your codebase
(this correlates with PATCH in semantic versioning).
feat: a commit of the type feat introduces a new feature to the codebase
(this correlates with MINOR in semantic versioning).
BREAKING CHANGE: a commit that has the text BREAKING CHANGE: at the beginning of
its optional body or footer section introduces a breaking API change
(correlating with MAJOR in semantic versioning).
A BREAKING CHANGE can be part of commits of any type.
Others: commit types other than fix: and feat: are allowed,
like chore:, docs:, style:, refactor:, perf:, test:, and others.
このコマンドは、コミットメッセージの書き方を教えてくれます。ここでは、[pattern].MESSAGEという形式で記述することにします。つまり、次のようなメッセージとなります。
code: bash
$ cz check -m "test: all summarize_data tests now succeed"
Commit validation: successful!
いいですね、今回のコミットメッセージは有効です。でも、ちょっと待ってください。commitizenで毎回 メッセージをチェックするのは面倒ですし、適用されるべき保証もありません。それよりも、git commit コマンドを使うたびに自動的にメッセージをチェックしたほうがいいでしょう。そこで活躍するのがpre-comitフックです。
pre-commit で Git のメッセージ規約を自動的に適用する
Git フックは、Git のライフサイクルの中の特定の場所で何らかのアクションを自動化して実行するのに便利です。pre-commit フックでは、Git のコミットが発行される前にスクリプトを実行することができます。このフックを使ってコミットメッセージを検証し、Git が私たちの期待にそぐわないメッセージを使うのを防ぐことができます。このフックは、コマンドラインからだけでなく、フックが登録されているGitリポジトリとやりとりするすべてのツールからも有効です(お気に入りのIDEを含む)。
pre-commit は、多言語のプレコミットフックを管理・維持するためのフレームワークです。pre-commitの内部構造や、pre-commitフックの可能性について詳しく知りたい場合は、使用説明書をご覧ください。 pre-commitをインストールするには、次のように実行します。
code: bash
$ peotry add -D pre-commit
Gitのコミット検証を自動化するためには、まず、設定ファイル.pre-commit-config.yaml を以下のように作成します。
code: .pre-commit-config.yaml
repos:
rev: master
hooks:
- id: commitizen
次に、フックのソースをrepoプロパティで定義してインストールします。
code: bash
$ pre-commit install --hook-type commit-msg
これですべての設定が完了したので、Git フックを使うことができます。
code: bash
$ git commit -m "test: all summarize_data tests now succeed"
INFO Once installed this environment will be reused. INFO This may take a few minutes... commitizen check.........................................................Passed
INFO Restored changes from /home/fbraza/.cache/pre-commit/patch1617970841. 2 files changed, 48 insertions(+), 5 deletions(-)
rewrite tests/test_summarize_dataframe.py (98%)
pre-commit は、チェックを実行するための環境をインストールします。ここでは、コミットメッセージの評価が通過していることがわかります。最後に、ビルドファイル(poetry.lock、pyproject.toml)とモジュールに加えられた変更をコミットし、プッシュすることができます。
code: bash
$ git commit -m "build: add developer dependencies" -m "commitizen and pre-commit added to our dev dependencies"
commitizen check.........................................................Passed
2 files changed, 585 insertions(+), 1 deletion(-)
git commit -m "feat: implementation of the summary function to summarize dataframe"
commitizen check.........................................................Passed
1 file changed, 94 insertions(+)
これで、すべてをGitHubリポジトリにプッシュできるようになりました。
code: bash
$ git push origin master
まとめ
いくつかのトピックを取り上げました。
まず最初に、コードのユニットテストの書き方について説明します。コードを書く前に、必ずテストを書き始めなければなりません。テストを書くことで、実装する前にAPIと期待値を明確にすることができます。その結果、確実に利益を得ることができるでしょう。Pythonの標準ライブラリに含まれているunittestを使用しました。私はunittestのシンプルなデザインとオブジェクト指向のアプローチが好きですが、他の人はpytestライブラリを使うことを好みます。非常に便利な点として、pytest は unittest.TestCase クラスを最初からサポートしています。2つのライブラリのどちらか、あるいは必要に応じて両方を組み合わせてテストを書くことができ、1つの共通コマンドですべてのテストを実行することができます。
Git のコミットメッセージを書く際にグッドプラクティスを徹底する方法を見てきました。私たちが提案するソリューションは、commitizen と pre-commit というふたつの異なる Python パッケージを使用することで実現します。最初のパッケージは、メッセージがあなたの選んだ規約に沿っているかどうかをチェックするツールを提供します。2つ目のパッケージは、Gitフックを使ってプロセスを自動化します。
次回、最後の記事では、さらに一歩進んでみましょう。toxを使ってテストを自動化し、それをCI/CDパイプラインの中に統合します。その後、パッケージを準備し、最終的にPoetryを使ってPyPiで公開する方法を紹介します。
チートシート
poetry
依存パッケージをプロジェクトに追加
code: bash
開発時の依存パッケージをプロジェクトに追加
code: bash
code: bash
テスト実行
code: bash
$ poetry run pytest
commitizen
commitizen の初期化
code: bash
$ cz init
コミットメッセージをチェック
code: bash
$ cz check -m "YOUR MESSAGE"
pre-commit
デフォルトの設定ファイルを生成
code: bash
$ pre-commit sample-config
git hook をインストール
code: bash
参考資料